Repository navigation
ble: offer to clear a stale phone pairing, once, after asking first - #1790
Merged
Merged
Conversation
NOOP has always DETECTED the stale bond - a bonded fast-path connect that drops before it ever reaches a session means the OS holds a pairing the strap no longer honours - and from the second failure it shows the forget-and-re-pair guide. Step 2 of that guide is "Open Settings > Bluetooth, find your WHOOP, and Forget". This does that step for the user when asking did not work. Field evidence it is reachable: log 260901-1022 shows `stale OS bond (attempt 1)` and `(attempt 2)` on a real strap. OFF by default, behind its own switch, per the rule every strap-affecting probe in PuffinExperiment follows. removeBond() DELETES a pairing from the phone's Bluetooth settings - at least as consequential as createBond() forming one, which shouldRequestExplicitBond already gates the same way - so it must not ride in on consent given for something else. The key joins FIVE_MG_GATED_KEYS, whose doc says two lists would drift. Five failures, not the two where the guide appears: the guide asks the user to act and they deserve cycles to do it before the app acts for them. Once per streak, cleared only by a genuine bond - removing a pairing and failing again means the pairing was not the problem, and repeating would delete one per reconnect for a cause it cannot fix. removeBond() is not public API, so it goes through reflection and a refusal is a normal outcome: the log says whether Android ACCEPTED or REFUSED, because "we asked" and "it happened" are different facts and a user reading the line needs to know which they got. No MAC, no serial. The isWhoop5 guard is belt and braces - staleDirectBond can only be set by the Easy-connect path, which is 5/MG-only - and it is kept because connectedFamily fails CLOSED when stale. The direction that matters is never removing a pairing we should not. Threshold and once-only shape are taken as a FACT from OpenStrap/edge's gen5 bootstrap, which removes the platform bond once at five consecutive exchange failures and clears the counter only on a connect reaching READY. edge is Dart and none of its code is here. Android-only: CoreBluetooth exposes no bond-removal API at all, so there is nothing to twin. 5025 unit tests green; release lintVital and the i18n audit clean; the switch label is localized in all six locales.
Re-review of my own PR: the row went into SettingsScreen, inside
if (false && showFiveMGControls) {
whose comment says it outright - "Developer-only 5/MG controls now live in Test
Centre. Keep the implementation below during the compatibility transition, but
never render a second copy in everyday Settings." I had added a toggle nobody
could reach, next to an explicitBond switch that is equally unreachable and only
appears to work because the pref persists from before that block was disabled.
Moved to TestCentreScreen, beside "Ask Android to pair" and "Try history sync
without pairing" - the two switches this escalates from, now sitting together
where a reader meets them in one place. Inside , which is live.
That also corrects the string. Test Centre titles use the raw_diag_* namespace,
so the l10n_settings_screen_* key is gone from all seven files and replaced by
raw_diag_clear_stale_bond, translated in the same six locales. The developer
detail beside it stays an English literal, as every sibling row's does.
Nothing was added to the dead block: a row that never shipped in Settings needs
no compatibility copy, and adding one would only be more to delete later.
5025 unit tests green; release lintVital, the i18n audit and doc lint clean.
The bug the last commit fixed had no test that could have caught it. Every assertion passed and all four checks were green while the toggle sat in SettingsScreen's `if (false && showFiveMGControls)` block, unreachable. A UI switch has no assertion that says "a user can get to me". So this reads the source, the way SystemInitiatedConnectTest does for connect entry points: the toggle must be wired in Test Centre and bound to its experiment, Test Centre must not have grown a disabled block of its own, and the retained-but-dead Settings copy must not have gained it back. Verified it can fail by unwiring the toggle and watching this one assertion go red, then restoring. It fails loudly rather than skipping when the source is not found, for the same reason the push entry-point gate does. 5026 unit tests green.
ryanbr
added a commit
that referenced
this pull request
Sep 1, 2026
A mirror of #1790 is not safely buildable. This records why, and corrects a claim I made in that PR's merge body. #1790 said "Android-only and permanently so: CoreBluetooth exposes no bond-removal API." True, and not the whole blocker - stated that way it reads as settled and invites the next reader to go hunting for a way round. The real obstacle is IDENTITY. IOBluetooth, whatever it can or cannot do to a BLE pairing, addresses devices by MAC, and Apple's side never has one: BLEManager.epitaphLine's own doc already says the peripheral UUID is "per-install, not the hardware address". Grepped BLEManager unfiltered to be sure - the only address mentions are that doc line and the new note. The one bridge left is matching IOBluetoothDevice.pairedDevices() by NAME, and deleting a pairing on a name match removes the wrong device the moment a user has two straps or has renamed one. That is exactly the mistake shouldRemoveStaleBond's guards exist to prevent, so closing the gap that way is worse than leaving it open. Worth writing down because Apple is not unaware of the condition: didFailToConnect already handles CBError.peerRemovedPairingInformation and publishes the same forget-and-re-pair guide, naming the strap. Both platforms detect it, both give the same advice, only one can take the step. Three re-review passes, all on this note's own prose rather than the conclusion. It asserted an Apple SDK capability inside a correction about unverified assertions; it re-derived a fact the codebase already stated; and it held iOS to a looser standard than macOS, including a claim about Apple's design intent. Each is gone. The technical conclusion never moved. Notes only, one per platform, cross-referencing. 638 BLE tests green. Left open deliberately: Apple's stale-bond branch sets the guide and returns, scheduling nothing, where Android schedules a reconnect - so a user who clears the pairing in System Settings while the app is open is not picked up until they reconnect by hand. That may well be deliberate, and it is not a change to make on one reading of it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ported as a fact from OpenStrap/edge's gen5 bootstrap, which removes the platform bond once after five consecutive exchange failures and clears the counter only when a connect reaches READY. edge is Dart; none of its code is here.
What it does
NOOP has always detected the stale bond — a bonded fast-path connect that drops before it ever reaches a session means the OS holds a pairing the strap no longer honours (a firmware update, or the official WHOOP app re-bonding). From the second failure it shows the forget-and-re-pair guide, whose step 2 is:
This does that step for the user when asking did not work. Field evidence it is reachable: log
260901-1022showsstale OS bond (attempt 1)and(attempt 2)on a real strap.Why it is opt-in and default off
removeBond()deletes a pairing from the phone's Bluetooth settings. That is at least as consequential ascreateBond()forming one, whichshouldRequestExplicitBondalready gates behind its own switch — andExplicitBond's doc states the rule outright: a probe that changes OS state "must not ride in on consent given for something else."The key joins
FIVE_MG_GATED_KEYS, whose own doc warns that two lists would drift.The thresholds, and why they differ from the guide's
Five failures, not the two where the guide appears. The guide asks the user to act; they deserve several reconnect cycles to do it before the app acts for them. A pairing that really is stale reaches five within a couple of minutes.
Once per streak, cleared only by a genuine bond. Removing a pairing and immediately failing again means the pairing was not the problem, and repeating would delete one per reconnect for a cause it cannot fix. A genuine bond is the event that proves phone and strap agree again.
Honest about what the OS may say
removeBond()is not public API, so it goes through reflection and a refusal is a normal outcome, not a crash. The log distinguishes them:"We asked" and "it happened" are different facts, and a user reading the line needs to know which they got. No MAC, no serial — pinned by a test that matches the shape of a MAC rather than the
:character, since the prose itself contains colons.Guards
Each clause is pinned separately, because getting one wrong means removing a working pairing:
0..4isWhoop5is belt and braces:staleDirectBondcan only be set by the Easy-connect path, which is 5/MG-only and pins the model. It is kept becauseconnectedFamilyfails closed when stale — the direction that matters is never removing a pairing we should not.Parity
Android-only, and permanently so. CoreBluetooth exposes no bond-removal API at all, so there is nothing to twin — the same situation
ExplicitBondandBondStateTracedocument with theirPARITY:notes.Verification
StaleBondRemovalTest.lintVitalFullRelease(fatalExtraTranslation) clean, andTools/i18n_audit.pyexits 0.values/had it.Tools/doc_comment_lint.pyclean.What it will not do
It does not make a strap that refuses SMP pair. On a 5/MG answering
Pairing Not Supported(0x05) this clears a stale pairing so the failing bonded fast-path stops being attempted — it does not reach the handshake. #1635's designed end state is unchanged.